iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Kubernetes

防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線系列 第 22

Day 22:把 unit-test 接進 Tekton DAG —— 測試全過,Task 還是紅燈

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260822/20183337B4HlPUZ7JC.png

今日目的:把 unit-test 加進 Pipeline,並且讓它紅燈時你看得懂在講什麼。
先備知識:銜接 Day 21〈Trivy fs〉。
本篇所有數字、log、PipelineRun 狀態都是 2026-08-22 在 ci namespace 實跑的。
環境:CRC 4.21.14 / Nx 23.1.1 / Jest 30.3 / 單一專案 apps/web

Jest 是什麼

Jest 是 JavaScript/TypeScript 的測試框架。*.spec.ts 裡的 describeitexpect 就是它的 API,nx test 底下實際在跑的也是它。

本文只用到它的三件事:跑測試算覆蓋率拿覆蓋率去比對門檻(沒過就 exit 1)。

覆蓋率報表的四個欄位,全篇一直出現:

  • Statements:可執行的敘述句有幾條被跑到
  • Branchesif/三元運算子這種分岔,每一條路有沒有走過
  • Functions:函式有沒有被呼叫過
  • Lines:行數,跟 Statements 接近但不相等(一行可能有多條敘述)

設定寫在 apps/web/jest.config.cts,Nx 則是透過 @nx/jest:jest executor 去呼叫它 —— 這個中間層等一下會製造麻煩(見 §10.2、§10.3)。

懶人包

第一部(快樂路徑)unit-test 接在 npm-build 之後、跟 eslint-check 並行。Task 只有一個 step、五個旗標,jest.config.cts 只加三段。整個 Task 跑完 7~21 秒

第二部(紅燈處理):紅燈的訊息長這樣 ——

Jest: Uncovered count for statements (46) exceeds global threshold (42)
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total

測試全過,Task 還是紅燈。 它擋的是「覆蓋率退步」,不是「測試壞掉」。修法是補測試 —— 而且要補夠:分支沒走到,照樣紅燈。

開場對照表

項目 unit-test 之前 之後
DAG npm-build → eslint-check npm-build → eslint-checkunit-test
測試檔全刪掉 沒有這道關卡 exit 1,No tests found, exiting with code 1
覆蓋率報表 沒有 文字表格印在 Pod log
帳面覆蓋率 100 / 100 / 100 / 100(假的,只算 3 個檔) 22.22 / 0 / 16.66 / 16(9 個檔)
新增沒測的程式碼 綠燈通過 unit-test StepFailed
整條 Pipeline 耗時 只多約 20 秒(跟 eslint-check 並行)


第一部:快樂路徑

一、DAG 位置

unit-test 需要 node_modules,所以接在 npm-build 之後。它跟 eslint-check 沒有先後關係,Tekton 會讓兩者並行:

git-clone ──┬─► workspace-probe ─┐
            ├─► gitleaks-scan ───┤
            ├─► semgrep-scan ────┼─► npm-build ──┬─► eslint-check
            └─► sca-scan ────────┘               └─► unit-test
  - name: unit-test
    runAfter:
    - npm-build
    params:
    - name: NX_PROJECT
      value: web
    taskRef:
      kind: Task
      name: unit-test
    workspaces:
    - name: source
      workspace: shared-workspace

npm-buildrunAfter 是四個 Task 的匯流點gitleaks-scansemgrep-scansca-scanworkspace-probe 全部完成才開始),不是鬆散的扇出。四個掃描裡任何一個紅燈,npm-build 和它後面的 eslint-checkunit-test 全部不會跑。

並行的好處:一次 run 就同時知道「風格/型別有沒有問題」和「覆蓋率有沒有退步」。實測 unit-test 只花 7~21 秒,整條線的 critical path 幾乎不變。


二、Task 設定

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: unit-test
  namespace: ci
spec:
  params:
    - name: NX_PROJECT
      type: string
      default: web
  steps:
    - name: test
      image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/library/node:22-alpine
      workingDir: $(workspaces.source.path)
      env:
        - name: NODE_OPTIONS
          value: --max-old-space-size=3072
        - name: NX_DAEMON
          value: "false"
        - name: CI
          value: "true"
      script: |
        #!/bin/sh
        set -e
        test -d node_modules || { echo "沒有 node_modules,npm-build 沒跑過"; exit 1; }

        npx nx run-many --target=test \
          --projects="$(params.NX_PROJECT)" \
          --parallel=1 \
          --coverage \
          --coverageReporters=text \
          --passWithNoTests=false \
          --skip-nx-cache
  workspaces:
    - name: source

2.1 五個旗標,各自在擋什麼

旗標 拿掉會怎樣
--coverage coverageThreshold 根本不會被檢查,門檻形同虛設
--coverageReporters=text 覆蓋率有量,但報表只產成 HTML 留在容器裡,Pod log 一個數字都沒有(見 §10.2)
--passWithNoTests=false 測試檔全消失也是綠燈(見 §10.3)
--skip-nx-cache nx 可能重播上一次的結果。被快取重播的綠燈,在 log 上跟真跑出來的綠燈長得一模一樣(見 §10.5)
--parallel=1 eslint-check 一致;單機不要讓多個 Node 行程搶記憶體(見 §12)

2.2 為什麼不自己 npm ci

node_modulesnpm-build 裝在 shared workspace,unit-test 直接用。實測 npm ci 走 Nexus 要 31 秒 / 1726 個套件 / 774.9 MB,重跑一次是純浪費。

第一行 test -d node_modules 是防呆:有人把 runAfter 拿掉時,它會明確報錯,而不是丟出一個看起來像測試失敗的錯誤。


三、jest.config.cts 的三段

Task 只是把旗標傳進去,真正決定「量什麼、怎麼報、擋在哪」的是設定檔。

apps/web/jest.config.cts(注意副檔名是 .cts,不是 .ts —— grep --include="*.ts" 掃不到它):

module.exports = {
  displayName: 'web',
  preset: '../../jest.preset.js',
  setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],

  coverageDirectory: '../../coverage/apps/web',

  // ① 報表出口
  coverageReporters: ['text', 'json-summary', 'html'],

  // ② 統計範圍
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.spec.ts',
    '!src/**/*.d.ts',
    '!src/test-setup.ts',
  ],

  // ③ 門檻
  coverageThreshold: {
    global: {
      statements: -42,
      branches: -8,
      functions: -5,
      lines: -42,
    },
  },

  // 以下維持 repo 原本的內容
  transform: { /* ... */ },
};

3.1 ① 報表出口

@nx/jest 的 preset 把 coverageReporters 覆寫成 ['html'](Jest 原生預設是 ["json","lcov","text","clover"],含 text)。不覆寫的話報表只產成 HTML 留在容器裡,Pod 一結束就沒了。詳見 §10.2。

3.2 ② 統計範圍 —— 不設的話那個 100% 是假的

Jest 沒設 collectCoverageFrom 時,統計範圍是「測試執行過程中實際被 import 到的檔案」,不是整個專案的原始碼。

apps/web1 個 spec、9 個原始碼檔。不設的話:

File           | % Stmts | % Branch | % Funcs | % Lines |
---------------|---------|----------|---------|---------|
All files      |     100 |      100 |     100 |     100 |
 app.html      |     100 |      100 |     100 |     100 |
 app.ts        |     100 |      100 |     100 |     100 |
 nx-welcome.ts |     100 |      100 |     100 |     100 |

四項全部 100%,表格只有 3 行。 這個 100% 的真正意思是「被測到的那 3 個檔案測得不錯」。

補上 collectCoverageFrom 之後:

指標 不設 設了
Statements 100%(13/13) 22.22%(12/54)
Branches 100%(0/0) 0%(0/8)
Functions 100%(1/1) 16.66%(1/6)
Lines 100%(9/9) 16%(8/50)
表格檔案數 3 9

沒有任何一行程式碼改變,改的只是「要統計哪些檔案」。

注意 covered 從 13 掉到 12collectCoverageFrom 寫的是 src/**/*.tsapp.html 不是 .ts 就被排除了。
分子分母同時在動,比較兩份報表要看 covered/total,不要只看百分比。

3.3 排除項要有理由

排除 理由 決定
*.spec.ts 測試檔本身不該計入
*.d.ts 純型別宣告,沒有執行期程式碼
test-setup.ts 測試框架前置,不是產品程式碼
main.ts / server.ts 「不好測」不是理由。server.ts 有 66 行真邏輯 保留

留著它們的代價只是門檻數字大一點,但它們是真缺口,排掉就永遠看不到。Ratchet 讓你今天照樣通過,沒必要為了過關先動排除清單。


四、③ 門檻的數字怎麼來

4.1 不要用猜的,用今天的 covered/total 換算

指標 covered / total 未覆蓋 門檻
statements 12 / 54 42 -42
branches 0 / 8 8 -8
functions 1 / 6 5 -5
lines 8 / 50 42 -42

規則

這個指標目前狀態 寫法 語意
已經 100% 正數 100 只能維持,不能倒退
還有未覆蓋 負數的未覆蓋數量 今天幾個沒測到就是幾個,不准再多

Ratchet(棘輪):只能收緊、不能放寬,而且從今天就生效。

4.2 四種寫法的實測下場

門檻寫法 實測 exit threshold 訊息 結論
-0 0 0 行 ❌ 等於沒設(見 §10.4)
0 0 0 行 ❌ 永遠不觸發
100 1 threshold for statements (100%) not met: 22.22% ❌ 補齊前每次紅燈
-42 / -8 / -5 / -42 0 0 行 ✅ 今天就能過

五、跑起來長什麼樣(綠燈)

push 之後 gitea webhook 自動觸發 d15-happy-path(Pipeline):

$ oc get pipelinerun d14-run-dnxjq -n ci \
    -o custom-columns=NAME:.metadata.name,PIPELINE:.spec.pipelineRef.name,MSG:.status.conditions[0].message

NAME            PIPELINE         MSG
d14-run-dnxjq   d15-happy-path   Tasks Completed: 8 (Failed: 0, Cancelled 0), Skipped: 0
$ oc get taskrun -n ci -l tekton.dev/pipelineRun=d14-run-dnxjq
NAME                            SUCCEEDED   REASON      STARTTIME   COMPLETIONTIME
d14-run-dnxjq-git-clone         True        Succeeded   8m15s       7m49s
d14-run-dnxjq-workspace-probe   True        Succeeded   7m49s       7m3s
d14-run-dnxjq-gitleaks-scan     True        Succeeded   7m49s       7m42s
d14-run-dnxjq-semgrep-scan      True        Succeeded   7m49s       7m33s
d14-run-dnxjq-sca-scan          True        Succeeded   7m48s       7m37s
d14-run-dnxjq-npm-build         True        Succeeded   7m3s        46s
d14-run-dnxjq-eslint-check      True        Succeeded   46s         13s
d14-run-dnxjq-unit-test         True        Succeeded   46s         7s

unit-test 的 Pod log:

> nx run web:test --coverage --coverageReporters=text --passWithNoTests=false

 PASS   web  apps/web/src/app/format-utils.spec.ts
 PASS   web  apps/web/src/app/app.spec.ts
-----------------------|---------|----------|---------|---------|-------------------
File                   | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------------|---------|----------|---------|---------|-------------------
All files              |   27.58 |       20 |   28.57 |   22.22 |
 src                   |       0 |        0 |       0 |       0 |
  main.server.ts       |       0 |      100 |       0 |       0 | 1-8
  main.ts              |       0 |      100 |       0 |       0 | 1-6
  server.ts            |       0 |        0 |       0 |       0 | 1-66
 src/app               |   53.33 |      100 |     100 |   46.15 |
  app.config.server.ts |       0 |      100 |     100 |       0 | 1-12
  app.config.ts        |       0 |      100 |     100 |       0 | 1-6
  app.routes.server.ts |       0 |      100 |     100 |       0 | 1-3
  app.routes.ts        |       0 |      100 |     100 |       0 | 3
  app.ts               |     100 |      100 |     100 |     100 |
  format-utils.ts      |     100 |      100 |     100 |     100 |
  nx-welcome.ts        |     100 |      100 |     100 |     100 |
-----------------------|---------|----------|---------|---------|-------------------

Test Suites: 2 passed, 2 total
Tests:       3 passed, 3 total

 NX   Successfully ran target test for project web

快樂路徑到此結束。 接下來是這道關卡真正的價值:它紅燈的時候。



第二部:unit-test 失敗的處理

六、紅燈長什麼樣

有人 push 了一個沒有測試的檔案:

// apps/web/src/app/format-utils.ts
export function formatDisplayName(name: string): string {
  if (!name) {
    return 'Unknown';
  }
  return name.trim().toUpperCase();
}

5 行、1 個 function、1 個 branch。webhook 觸發的 d15-happy-path(Pipeline):

$ oc get pipelinerun d14-run-rdvn4 -n ci
NAME            PIPELINE         MSG
d14-run-rdvn4   d15-happy-path   Tasks Completed: 8 (Failed: 1, Cancelled 0), Skipped: 0
$ oc get taskrun -n ci -l tekton.dev/pipelineRun=d14-run-rdvn4
NAME                            SUCCEEDED   REASON       STARTTIME   COMPLETIONTIME
d14-run-rdvn4-git-clone         True        Succeeded    3m38s       3m13s
d14-run-rdvn4-workspace-probe   True        Succeeded    3m13s       2m26s
d14-run-rdvn4-gitleaks-scan     True        Succeeded    3m13s       3m6s
d14-run-rdvn4-semgrep-scan      True        Succeeded    3m12s       2m57s
d14-run-rdvn4-sca-scan          True        Succeeded    3m12s       3m1s
d14-run-rdvn4-npm-build         True        Succeeded    2m26s       53s
d14-run-rdvn4-eslint-check      True        Succeeded    53s         25s
d14-run-rdvn4-unit-test         False       StepFailed   53s         21s

第一步永遠是看 TaskRun 清單,不是看 PipelineRun 的紅字。 這裡一眼就知道:eslint-check 綠、只有 unit-test 紅。


七、怎麼讀那段 log

 PASS   web  apps/web/src/app/app.spec.ts
  App
    ✓ should render title (220 ms)

-----------------------|---------|----------|---------|---------|-------------------
File                   | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------------|---------|----------|---------|---------|-------------------
All files              |   20.68 |        0 |   14.28 |   14.81 |
 src/app               |      40 |        0 |      50 |   30.76 |
  app.ts               |     100 |      100 |     100 |     100 |
  format-utils.ts      |       0 |        0 |       0 |       0 | 1-5      ← 兇手
  nx-welcome.ts        |     100 |      100 |     100 |     100 |
-----------------------|---------|----------|---------|---------|-------------------
Jest: Uncovered count for statements (46) exceeds global threshold (42)
Jest: Uncovered count for branches (10) exceeds global threshold (8)
Jest: Uncovered count for lines (46) exceeds global threshold (42)
Jest: Uncovered count for functions (6) exceeds global threshold (5)
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        8.628 s
Ran all test suites.


 NX   Running target test for project web failed

7.1 四個該看的地方

位置 意思
Tests: 1 passed, 1 total 測試全過。 紅燈完全不是測試壞掉,是覆蓋率退步
Uncovered count for statements (46) exceeds global threshold (42) 講的是數量不是百分比 —— 這正是負數門檻的語意。超過 4 個
format-utils.ts | 0 | 0 | 0 | 0 | 1-5 兇手直接列在表上,還標了未覆蓋行號
Cache: Skipped (--skip-nx-cache) 這條 log 是這次真跑出來的,不是重播的

7.2 一眼算出還差多少

指標 門檻 現在
statements 42 46 +4
branches 8 10 +2
functions 5 6 +1
lines 42 46 +4

那 5 行程式碼帶進來 4 個 statement、2 個 branch、1 個 function、4 行。通通要覆蓋掉。


八、怎麼修 —— 補測試,而且要補夠

// apps/web/src/app/format-utils.spec.ts
import { formatDisplayName } from './format-utils';

describe('formatDisplayName', () => {
  it('大寫化並去掉前後空白', () => {
    expect(formatDisplayName('  ada lovelace ')).toBe('ADA LOVELACE');
  });

  // 這一條不是湊數:少了它,if (!name) 那個分支不會被走到,
  // branches 未覆蓋數會是 9 > 8,unit-test 還是紅燈。
  it('空字串回傳 Unknown', () => {
    expect(formatDisplayName('')).toBe('Unknown');
  });
});

8.1 ⚠️ 「補了測試」不等於「補夠了測試」

如果只寫第一條測試:

  • function 走到了 ✅
  • statements、lines 大部分覆蓋了 ✅
  • if (!name) 的 true 分支沒走到 ❌ → branches 未覆蓋 9 > 8

測試有寫、也通過了,Task 還是紅燈。

這是整篇最容易踩到的地方。看到 branches 那一行沒降下來,就是分支沒走完,不是測試沒寫。


九、修完之後:綠燈是「剛好」過的

補上完整的 spec 之後(d14-run-dnxjq,見 §五),四項未覆蓋數量:

指標 總數 已覆蓋 未覆蓋 門檻 餘裕
statements 58 16 42 42 0
branches 10 2 8 8 0
functions 7 2 5 5 0
lines 54 12 42 42 0

四項全部剛好卡在門檻上,一個都不能少。

這正是 Ratchet 的設計意圖:新增的程式碼必須自己把自己覆蓋掉,不能吃既有的餘裕。

9.1 兩筆紀錄對照

紅(d14-run-rdvn4 綠(d14-run-dnxjq
Statements 20.68% 27.58%
Branches 0% 20%
Functions 14.28% 28.57%
Lines 14.81% 22.22%
format-utils.ts 0 / 0 / 0 / 0 100 / 100 / 100 / 100
Test Suites 1 passed 2 passed
Tests 1 passed 3 passed
unit-test StepFailed Succeeded
PipelineRun Failed(8 完成 / 1 失敗) Succeeded(8 完成 / 0 失敗)

兩筆之間只加了一個檔案。 門檻是雙向可靠的:踩線會擋,補上會放行。


十、五種會讓你誤判的情況

10.1 兩道關卡同時紅,分不出是誰擋的

同一批 commit 裡還有一筆 d14-run-6hqvx

PipelineRun eslint-check unit-test
d14-run-6hqvx StepFailed StepFailed
d14-run-rdvn4 Succeeded StepFailed
d14-run-dnxjq Succeeded Succeeded

第一筆的 eslint-check 是被一個手誤擋的:jest.config.cts'\\.' 少打一個反斜線變成 '\.'

jest 完全無感(正則裡未逃脫的 . 是「任意字元」,一樣會匹配),eslint 的 no-useless-escape 有感

於是兩道關卡同時紅,只看 PipelineRun 的 Failed 完全分不出來。

教訓unit-testeslint-check 並行的代價,就是紅燈時要多看一步 TaskRun 清單。這個代價值得付 —— 換來的是一次 run 就知道兩件事。

10.2 覆蓋率有量,但 log 上什麼都沒有

nx test web --coverage
→ exit 0,測試 PASS,Pod log 上沒有任何覆蓋率表格

第一次撞到會以為「旗標被 Nx 吃掉了」。不是。 進容器看:

coverage/apps/web/index.html
coverage/apps/web/base.css
coverage/apps/web/prettify.js

jest --showConfig 給出答案:

coverageReporters = ["html"]

@nx/jest 的 preset 把它覆寫成 ['html']覆蓋率量了、報表產了、寫進容器了,然後隨 Pod 一起消失。

這比「旗標被吃掉」難發現得多:行為不是「沒量」,是「量了但你看不到」。而且如果設了 coverageThreshold,門檻是真的在檢查 —— 只是通過或失敗的理由你在 log 上讀不到。

修法--coverageReporters=text,或設定檔寫 coverageReporters: ['text', 'json-summary', 'html']

10.3 測試檔全消失,卻是綠燈

整個 repo grep passWithNoTests一次都沒出現。但:

$ mv apps/web/src/app/app.spec.ts /tmp/
$ nx test web
No tests found, exiting with code 0
→ exit 0,NX  Successfully ran target test for project web

放水的是 @nx/jest:jest executor 的預設值,設定檔上完全看不到。

$ nx test web --passWithNoTests=false
No tests found, exiting with code 1
Run with `--passWithNoTests` to exit with code 0
→ exit 1

危險的不是「有人加了一個放水旗標」,是「預設就放水,而且 code review 時看不到」。testMatch 寫錯、測試目錄被誤刪、新專案還沒寫測試 —— 在 CI 上全部是綠燈,diff 裡沒有任何線索。

這道關卡的邊界:擋得住「一個 spec 都沒有」,擋不住「有 1 個 spec 只涵蓋一小角」。門檻從「零」提高到「一」。剩下的交給 coverageThreshold

10.4 -0 這個門檻是空的

想表達「未覆蓋數量上限是零」而寫 -0

  1. Jest 判斷門檻類型的邏輯本質上是 threshold < 0
  2. JavaScript 裡 -0 < 0false
  3. 所以掉到百分比下限分支,變成 actual < -0永遠通過

實測佐證:-0 那一輪 exit 0,而且 log 裡 threshold 相關訊息是 0 行 —— 它連檢查都沒去做

已達標的指標用正數 100

10.5 那個綠燈可能是重播的

nx 有 task cache。同一組輸入的 test target 跑過一次之後,下一次可能直接重播上次的結果。

被快取重播的綠燈,在 log 上跟真跑出來的綠燈長得幾乎一樣。 所以 Task 裡加了 --skip-nx-cache,log 會明確寫:

Cache: Skipped (--skip-nx-cache)

每個 PipelineRun 都是新 clone,快取本來就是冷的,成本接近零。

eslint-check 目前沒有加,這是既有的不一致,不是本篇引入的。


十一、更根本的限制:覆蓋率量的是「執行」,不是「驗證」

加一個完全沒有 expect 的測試:

import * as routes from './app.routes';
it('touches the code', () => { void routes; });

實測:

指標 加之前 加之後
Statements 22.22%(12/54) 24.07%(13/54)
Lines 16%(8/50) 18%(9/50)
app.routes.ts 0% 100%

測試通過,覆蓋率上升,app.routes.ts 從 0% 變 100% —— 而這個測試沒有驗證任何事情。

覆蓋率門檻擋的是「這段程式碼有沒有被執行過」,不是「它有沒有被驗證過」。
跟 Day 21〈Trivy fs〉「0 個漏洞不代表掃描有效」是同一種問題的另一個面貌。

所以 coverageThreshold 不是品質保證,是退步偵測。 它能告訴你「今天比昨天差」,不能告訴你「今天夠好」。當成前者用是對的,當成後者用會出事。


十二、記憶體:NODE_OPTIONS 的數字要自己量

情境 峰值 RSS(全容器,1 秒取樣)
不設 collectCoverageFrom 517 MB / 689 MB
設了 collectCoverageFrom 1441 MB / 1722 MB
Node 在這台的預設 heap 上限 2096 MB

補上統計範圍讓記憶體漲了約 2.5 倍 —— instrument 的檔案從 3 個變 9 個,其中 nx-welcome.ts 一個檔就 809 行。

1722 MB 對 2096 MB,只剩約 18% 餘裕,而這還只有 1 個專案 9 個檔案。所以設:

- name: NODE_OPTIONS
  value: --max-old-space-size=3072

3072 是從 1722 推的(約 1.8 倍),設定後實測 heap 上限變成 3120 MB。

量之前我以為「一個專案應該不用設」,結果 collectCoverageFrom 一加就逼到 82%。
兩個方向的直覺都是錯的,只有量出來的數字是對的。

12.1 為什麼不設 Kubernetes memory limit

Allocatable:  memory 11781652Ki(約 11.2 GiB)
Allocated:    memory requests 10181Mi (88%)

記憶體 requests 已在 88%,餘裕約 1.5 GiB。unit-test 加 memory request 會很接近排不進去。目前不設 request/limit 反而是它跑得起來的原因。換到有餘裕的叢集要設;在這台,先確認排得進去再設


十三、環境備註(重跑時會遇到)

現象 說明
git-clonecannot copy ... pre-push.sample: File exists 固定 PVC pipeline-source-pvc 有上一輪殘留。手動建 PipelineRun 時改用 volumeClaimTemplate
push 之後自動多出 d14-run-* gitea webhook → el-d14-gitea-ci EventListener,它的 pipelineRef 就是 d15-happy-path(Pipeline)
多條 pipeline 同時跑時 oc 斷線 這台記憶體 requests 已在 88%,偶發 wsarecv: connection forcibly closed,重試即可
grep --include="*.ts" 掃不到設定檔 檔名是 jest.config.cts。要用 --include="*.[cm]?ts" 或直接 find

實務備忘

接關卡時

  • [ ] unit-test 接在 npm-build 之後,跟 eslint-check 並行 —— 它要 node_modules
  • [ ] Task 第一行放 test -d node_modulesrunAfter 被拿掉時會明確報錯
  • [ ] 不要在 unit-test 裡自己 npm ci —— 那是 31 秒 / 774.9 MB 的純浪費

設定覆蓋率時

  • [ ] 先看報表有幾行 —— 表格行數 < 原始碼檔案數,那個百分比就是假的。這個檢查花 5 秒
  • [ ] 設 collectCoverageFrom —— 統計範圍沒修之前,門檻設多少都沒意義
  • [ ] 確認 coverageReporterstext —— Nx preset 預設 ['html']
  • [ ] 排除項要有理由 —— *.d.ts*.spec.ts 可以;「這個檔不好測」不可以
  • [ ] 門檻用今天的 covered/total 換算;已達標用正數 100不要用 -0
  • [ ] 明確寫 --passWithNoTests=false —— 不要假設 repo 裡沒這字串就等於沒在放水
  • [ ] 加 --skip-nx-cache —— 否則不知道綠燈是跑出來的還是重播的
  • [ ] NODE_OPTIONS 的數字自己量。這台量到 1722 MB / 上限 2096 MB,所以設 3072

紅燈時

  • [ ] 先看 TaskRun 清單,不是 PipelineRun 的紅字 —— 並行的關卡可能不只一個在紅
  • [ ] 看 Tests: N passed —— 測試全過的話,擋的是覆蓋率不是測試
  • [ ] 看 Uncovered count (X) exceeds (Y) —— 差幾個一目瞭然
  • [ ] 看表格裡哪個檔是 0%,還有 Uncovered Line #s
  • [ ] 補測試之後確認 branches 那一行也降下來了 —— 分支沒走完,測試通過也還是紅燈

架構總結

第一部做了三件事:

  1. unit-test 接進 DAG,跟 eslint-check 並行,整條線只多約 20 秒
  2. jest.config.cts 補上報表出口、統計範圍、門檻三段
  3. 門檻用今天真實的 covered/total 換算,做到「今天就能過」跟「以後不能變差」

第二部的重點只有一句:

Tests: 1 passed, 1 totalunit-test StepFailed 可以同時成立。

測試全過、Task 紅燈,這不是矛盾 —— 這道關卡量的本來就不是「測試有沒有壞」,是「覆蓋率有沒有退步」。看懂這件事,紅燈就從「哪裡壞了」變成「哪裡少測了」。

而修它的時候要記得:補了測試不等於補夠了測試。四項指標裡最容易漏的是 branches,因為「函式呼叫過了」跟「每條路都走過了」是兩回事。

這一路上三個不會報錯的陷阱 —— 統計範圍是空的、報表出口是 HTML、放水是預設值 —— 共同結構都一樣:CI 綠燈、log 正常、設定檔乾淨,而防線根本沒在防。

光看設定檔推論不夠,光看現象推論也不夠。要進去看它產出了什麼檔案,要實際踩一次線看它會不會叫。

明天(Day 23),我們將進入型別安全防線,探討多重 tsconfig 檢查的必要性,並實作 SBOM 產出時的除錯與解析作業。


外部參考文件

以下連結多半指向各專案的 latest 文件,跟本文實測的 Jest 30.3 / Nx 23.1.1 / CRC 4.21.14 不保證一致。換版本時以實測為準,文件只作為交叉查核用。

Jest

文件 對應本文
Configuring JestcollectCoverageFromcoverageThresholdcoverageReporters 的官方定義 §3、§4
Jest CLI Options--coverage--coverageReporters--passWithNoTests--showConfig §2.1、§10.2、§10.3
CoverageReporter.ts(Jest 原始碼)負數門檻的判斷就在這裡if (threshold < 0),以及 Uncovered count for ... exceeds ... threshold 這行訊息的來源 §4.2、§7.1、§10.4

Nx

文件 對應本文
@nx/jest Executors — executor 選項總覽 §10.3
@nx/jest:jest schema.json(Nx 原始碼) — executor 選項的權威來源,codeCoveragepassWithNoTests 的預設值要在這裡看,不是在 jest.config §10.3
Skip Task Caching--skip-nx-cache §2.1、§10.5
How caching works — 快取命中時「終端輸出會被原樣重播」的機制說明 §10.5

Tekton

文件 對應本文
PipelinesrunAfter 與 DAG 的並行行為 §1
Tasks — step、workspace、script §2
PipelineRuns — status 與 conditions 欄位,oc get pipelinerun 讀的就是這些 §5、§6
Compute Resources in Tekton — Step 的 request/limit 如何被加總、LimitRange 怎麼介入 §12.1
EventListeners — webhook 進來之後那顆 pod 是怎麼來的 §13

Node.js/JavaScript

文件 對應本文
Node.js Command-line APINODE_OPTIONS--max-old-space-size §12
相等比較與相同性(MDN 繁中)-0+0===== 下被視為相同 §10.4
Object.is()(MDN) — 唯一能分辨 -0 的比較方式,反過來證明 -0 < 0 為什麼是 false §10.4

其他

文件 對應本文
ESLint no-useless-escape — 那個「jest 無感、eslint 有感」的手誤,規則本體在這裡 §10.1
Gitea Webhooks — push 之後自動多出 PipelineRun 的起點 §13

上一篇
Day 21:第三道掃描 —— trivy fs 與五層過濾
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言